aegida-console / app / api / attachments / [id] / route.test.ts
route.test.ts
Raw
// @vitest-environment node

import { beforeEach, describe, expect, it, vi } from "vitest";

const { authenticateRequest, getAttachmentObject, getGateFileContent, getOwnedAttachment } =
  vi.hoisted(() => ({
    authenticateRequest: vi.fn(),
    getAttachmentObject: vi.fn(),
    getGateFileContent: vi.fn(),
    getOwnedAttachment: vi.fn(),
  }));

vi.mock("server-only", () => ({}));
vi.mock("@/lib/db/pool", () => ({ getPool: () => ({}) }));
vi.mock("@/lib/auth/server", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/auth/server")>()),
  authenticateRequest,
}));
vi.mock("@/lib/db/attachments", () => ({ getOwnedAttachment }));
vi.mock("@/lib/chat/gate-files", () => ({ getGateFileContent }));
vi.mock("@/lib/storage/s3", () => ({ getAttachmentObject }));

import { GET } from "@/app/api/attachments/[id]/route";
import { AuthError } from "@/lib/auth/server";

const attachment = {
  id: "00000000-0000-4000-8000-000000000077",
  name: "договор 1.txt",
  contentType: "text/plain",
  size: 15,
  gateFileId: "file-opaque_123",
  objectKey: null,
};

beforeEach(() => {
  authenticateRequest.mockReset().mockResolvedValue({
    id: "00000000-0000-4000-8000-000000000001",
    email: "alice@example.com",
  });
  getOwnedAttachment.mockReset().mockResolvedValue(attachment);
  getGateFileContent.mockReset().mockResolvedValue(streamOf("private contents"));
  getAttachmentObject.mockReset().mockResolvedValue(streamOf("legacy contents"));
});

describe("GET /api/attachments/:id", () => {
  it("streams Gate content through the authenticated same-origin route", async () => {
    const request = new Request(`http://localhost/api/attachments/${attachment.id}`, {
      headers: { Authorization: "Bearer exact-user-jwt" },
    });
    const response = await GET(request, { params: Promise.resolve({ id: attachment.id }) });

    expect(response.status).toBe(200);
    expect(response.headers.get("Location")).toBeNull();
    expect(response.headers.get("Cache-Control")).toBe("no-store");
    expect(response.headers.get("Content-Type")).toBe("text/plain");
    expect(response.headers.get("Content-Length")).toBe("15");
    expect(response.headers.get("Content-Disposition")).toBe(
      "attachment; filename*=UTF-8''%D0%B4%D0%BE%D0%B3%D0%BE%D0%B2%D0%BE%D1%80%201.txt",
    );
    expect(await response.text()).toBe("private contents");
    expect(getGateFileContent).toHaveBeenCalledWith(
      "file-opaque_123",
      "Bearer exact-user-jwt",
      expect.any(AbortSignal),
    );
    expect(getAttachmentObject).not.toHaveBeenCalled();
  });

  it("keeps legacy object-key rows downloadable during migration", async () => {
    getOwnedAttachment.mockResolvedValue({
      ...attachment,
      gateFileId: null,
      objectKey: "legacy/private-key",
    });

    const response = await GET(
      new Request(`http://localhost/api/attachments/${attachment.id}`, {
        headers: { Authorization: "Bearer exact-user-jwt" },
      }),
      { params: Promise.resolve({ id: attachment.id }) },
    );

    expect(await response.text()).toBe("legacy contents");
    expect(getAttachmentObject).toHaveBeenCalledWith("legacy/private-key");
    expect(getGateFileContent).not.toHaveBeenCalled();
  });

  it("does not resolve content when the caller is unauthorized", async () => {
    authenticateRequest.mockRejectedValue(new AuthError());

    const response = await GET(new Request("http://localhost/api/attachments/id"), {
      params: Promise.resolve({ id: "id" }),
    });

    expect(response.status).toBe(401);
    expect(getOwnedAttachment).not.toHaveBeenCalled();
    expect(getGateFileContent).not.toHaveBeenCalled();
    expect(getAttachmentObject).not.toHaveBeenCalled();
  });

  it("does not resolve content for another user's attachment", async () => {
    getOwnedAttachment.mockResolvedValue(null);

    const response = await GET(
      new Request("http://localhost/api/attachments/id", {
        headers: { Authorization: "Bearer exact-user-jwt" },
      }),
      { params: Promise.resolve({ id: "id" }) },
    );

    expect(response.status).toBe(404);
    expect(getGateFileContent).not.toHaveBeenCalled();
    expect(getAttachmentObject).not.toHaveBeenCalled();
  });
});

function streamOf(content: string): ReadableStream<Uint8Array> {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode(content));
      controller.close();
    },
  });
}